今天來繼續聊登入驗證~
昨天我們介紹了 Basic Auth,今天接著介紹 JWT
JWT 的全名是 JSON Web Token,與 Basic Auth 類似,一樣也是夾帶了帳號與密碼 (以及其他更多的資訊)。但不同的是,建立 JWT 時需要一把金鑰 (請好好保管 XD),一旦 JWT 被修改過,就會被擁有金鑰的單位發現。
換句話說,JWT 是可以被驗證有效性的
JWT 是由三個部份組合而成的,中間用一個 .
來隔開。這三個部份分別是:
header 主要是宣告這個 JWT 使用的演算法,這對後續驗證 JWT 來說是必要資訊。payload 則是放一些方便後續使用的資訊 (例如:帳號、使用者權限),通常也會放這個 JWT 的到期時間。signature 則是驗證 JWT 有效性的關鍵,它會把上面那兩個部份結合在一起後進行加密,因此,一旦無法用金鑰解密,或是資訊與上方不吻合都會被視為 JWT 無效。
JWT 也有不加密的,但這邊就不多討論了
大家可以去看看這篇文章,我覺得介紹得很好,也包含了很多理論的部份
另外,請大家一定要去 JWT.IO 這個網站動手玩玩看,相信對理解 JWT 會有幫助的。它也有整理大量的 JWT 套件 (常見語言都有),並且列出各個套件支援的功能與演算法。
JWT.IO 上 python 的套件有四個,這邊我們使用的是 python-jose
,與 FastAPI 官網範例使用相同的套件。
需要注意的是,安裝 python-jose
時需要額外安裝加密用的套件,詳情可以看Github的說明。加密套件的選擇有不只一個,官方推薦的是 pyca/cryptography,因此安裝 python-jose
的指令要使用
python-jose[cryptography]
但我自己是使用 pycryptodome,也沒有遇到什麼問題。
會選這個的原因是,有其他功能需要用到加解密,而那部份已經在使用pycryptodome
了
接下來我們來簡單的實作 JWT。這邊我使用的金鑰是直接複製 FastAPI 範例的,payload 內容則是使用 JWT.IO 的範例的預設值。
from jose import jwt
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token():
to_encode = {
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
if __name__ == "__main__":
token = create_access_token()
print(token)
執行後就會在 terminal 看到 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.HHg-h7KYt9hAKhSYmMPgFNu__j78RzWm-t4PfGuCWE4
,這就是我們產生出來的 JWT
另外,我們也可以把這個金鑰貼到 JWT.IO 網站範例右下的 VERIFY SIGNATURE 欄位,取代原本的 your-256-bit-secret
,確認使用的演算法是 HS256
(預設) 之後,就會發現左邊的 JWT 與我們剛剛在 terminal 看到的 JWT 是相同的。
大家可以去參考 FastAPI 官網的範例,程式碼太長我就不貼了,我挑幾個部份來解釋一下。
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
這部份就是負責產生 JWT 的函數,與上方範例不同的是,它多了到期時間的設定,因此會把當前時間加上有效時間的結果放進 payload 裡,也就是 to_encode
這個 dictionary
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
這部份則是驗證 JWT,使用的是 jwt.decode()
這個函數,如果
JWTError
)username
這個資訊HTTPException
,讓前端拿到 401 錯誤@app.post("/token", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
):
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
這個 API 比較像是一般的登入 API,負責回傳建立好的 JWT 給前端。
後端使用了 OAuth2PasswordRequestForm
,因此前端必須要用 username
和 password
把帳號密碼送到後端。後端拿到之後,會用 authenticate_user
函數 (沒貼程式碼) 驗證使用者,而驗證的標準有兩個:
authenticate_user
就會得到 False
,導致前端拿到 401 錯誤。若 authenticate_user
為 True
,就會開始製作 JWT,並回傳 JWT 給前端。
這邊寫「雜湊計算」也是相對簡單的寫法,精確一點應該是使用 bcrypt,但這邊就不多作介紹了
@app.get("/users/me/", response_model=User)
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)]
):
return current_user
這個就是一個需要 JWT 驗證的 API,在函數 input 內包含了 Depends()
,而由於 Depends()
內的函數會先被執行,因此如果在確認後發現沒有有效的 JWT,就會直接回傳 401 錯誤,不會進到 return current_user
這行程式碼。反之,如果 JWT 是有效的,那麼資料庫中的使用者資料就會被帶入這個 API 函數中,接著被回傳給前端。需要注意的是,因為有設定 response_model
,所以有部份資料被過濾掉 (例如:密碼),不會全部都送到前端去。
今天我們介紹了
然而,這部份其實還有很多東西可以討論,例如
但這討論下去就跟 FastAPI 沒有太大的關係了,因此身分驗證的主題就先告一個段落了,明天會開始介紹資料庫~